/**
 * @license
 * Copyright Google LLC All Rights Reserved.
 *
 * Use of this source code is governed by an MIT-style license that can be
 * found in the LICENSE file at https://angular.dev/license
 */

import { Position, SourceNode } from 'source-map';

// Matches <%= expr %>. This does not support structural JavaScript (for/if/...).
const kInterpolateRe = /<%=([\s\S]+?)%>/g;
// Matches <%# text %>. It's a comment and will be entirely ignored.
const kCommentRe = /<%#([\s\S]+?)%>/g;

// Used to match template delimiters.
// <%- expr %>: HTML escape the value.
// <% ... %>: Structural template code.
const kEscapeRe = /<%-([\s\S]+?)%>/g;
const kEvaluateRe = /<%([\s\S]+?)%>/g;

/** Used to map characters to HTML entities. */
const kHtmlEscapes: { [char: string]: string } = {
  '&': '&amp;',
  '<': '&lt;',
  '>': '&gt;',
  '"': '&quot;',
  "'": '&#39;',
  '`': '&#96;',
};

// Used to match HTML entities and HTML characters.
const reUnescapedHtml = new RegExp(`[${Object.keys(kHtmlEscapes).join('')}]`, 'g');

// Options to pass to template.
export interface TemplateOptions {
  sourceURL?: string;
  sourceMap?: boolean;
  module?: boolean | { exports: {} };
  sourceRoot?: string;
  fileName?: string;
}

function _positionFor(content: string, offset: number): Position {
  let line = 1;
  let column = 0;
  for (let i = 0; i < offset - 1; i++) {
    if (content[i] == '\n') {
      line++;
      column = 0;
    } else {
      column++;
    }
  }

  return {
    line,
    column,
  };
}

/**
 * A simple AST for templates. There's only one level of AST nodes, but it's still useful
 * to have the information you're looking for.
 */
export interface TemplateAst {
  fileName: string;
  content: string;
  children: TemplateAstNode[];
}

/**
 * The base, which contains positions.
 */
export interface TemplateAstBase {
  start: Position;
  end: Position;
}

/**
 * A static content node.
 */
export interface TemplateAstContent extends TemplateAstBase {
  kind: 'content';
  content: string;
}

/**
 * A comment node.
 */
export interface TemplateAstComment extends TemplateAstBase {
  kind: 'comment';
  text: string;
}

/**
 * An evaluate node, which is the code between `<% ... %>`.
 */
export interface TemplateAstEvaluate extends TemplateAstBase {
  kind: 'evaluate';
  expression: string;
}

/**
 * An escape node, which is the code between `<%- ... %>`.
 */
export interface TemplateAstEscape extends TemplateAstBase {
  kind: 'escape';
  expression: string;
}

/**
 * An interpolation node, which is the code between `<%= ... %>`.
 */
export interface TemplateAstInterpolate extends TemplateAstBase {
  kind: 'interpolate';
  expression: string;
}

export type TemplateAstNode =
  | TemplateAstContent
  | TemplateAstEvaluate
  | TemplateAstComment
  | TemplateAstEscape
  | TemplateAstInterpolate;

/**
 * Given a source text (and a fileName), returns a TemplateAst.
 */
export function templateParser(sourceText: string, fileName: string): TemplateAst {
  const children: TemplateAstNode[] = [];

  // Compile the regexp to match each delimiter.
  const reExpressions = [kEscapeRe, kCommentRe, kInterpolateRe, kEvaluateRe];
  const reDelimiters = RegExp(reExpressions.map((x) => x.source).join('|') + '|$', 'g');

  const parsed = sourceText.split(reDelimiters);
  let offset = 0;
  // Optimization that uses the fact that the end of a node is always the beginning of the next
  // node, so we keep the positioning of the nodes in memory.
  let start = _positionFor(sourceText, offset);
  let end: Position | null;

  const increment = reExpressions.length + 1;
  for (let i = 0; i < parsed.length; i += increment) {
    const [content, escape, comment, interpolate, evaluate] = parsed.slice(i, i + increment);
    if (content) {
      end = _positionFor(sourceText, offset + content.length);
      offset += content.length;
      children.push({ kind: 'content', content, start, end } as TemplateAstContent);
      start = end;
    }
    if (escape) {
      end = _positionFor(sourceText, offset + escape.length + 5);
      offset += escape.length + 5;
      children.push({ kind: 'escape', expression: escape, start, end } as TemplateAstEscape);
      start = end;
    }
    if (comment) {
      end = _positionFor(sourceText, offset + comment.length + 5);
      offset += comment.length + 5;
      children.push({ kind: 'comment', text: comment, start, end } as TemplateAstComment);
      start = end;
    }
    if (interpolate) {
      end = _positionFor(sourceText, offset + interpolate.length + 5);
      offset += interpolate.length + 5;
      children.push({
        kind: 'interpolate',
        expression: interpolate,
        start,
        end,
      } as TemplateAstInterpolate);
      start = end;
    }
    if (evaluate) {
      end = _positionFor(sourceText, offset + evaluate.length + 5);
      offset += evaluate.length + 5;
      children.push({ kind: 'evaluate', expression: evaluate, start, end } as TemplateAstEvaluate);
      start = end;
    }
  }

  return {
    fileName,
    content: sourceText,
    children,
  };
}

/**
 * Fastest implementation of the templating algorithm. It only add strings and does not bother
 * with source maps.
 */
function templateFast(ast: TemplateAst, options?: TemplateOptions): string {
  const module = options && options.module ? 'module.exports.default =' : '';
  const reHtmlEscape = reUnescapedHtml.source.replace(/[']/g, "\\\\\\'");

  return `
    return ${module} function(obj) {
      obj || (obj = {});
      let __t;
      let __p = '';
      const __escapes = ${JSON.stringify(kHtmlEscapes)};
      const __escapesre = new RegExp('${reHtmlEscape}', 'g');

      const __e = function(s) {
        return s ? s.replace(__escapesre, function(key) { return __escapes[key]; }) : '';
      };
      with (obj) {
        ${ast.children
          .map((node) => {
            switch (node.kind) {
              case 'content':
                return `__p += ${JSON.stringify(node.content)};`;
              case 'interpolate':
                return `__p += ((__t = (${node.expression})) == null) ? '' : __t;`;
              case 'escape':
                return `__p += __e(${node.expression});`;
              case 'evaluate':
                return node.expression;
            }
          })
          .join('\n')}
      }

      return __p;
    };
  `;
}

/**
 * Templating algorithm with source map support. The map is outputted as //# sourceMapUrl=...
 */
function templateWithSourceMap(ast: TemplateAst, options?: TemplateOptions): string {
  const sourceUrl = ast.fileName;
  const module = options && options.module ? 'module.exports.default =' : '';
  const reHtmlEscape = reUnescapedHtml.source.replace(/[']/g, "\\\\\\'");

  const preamble = new SourceNode(1, 0, sourceUrl, '').add(
    new SourceNode(1, 0, sourceUrl, [
      `return ${module} function(obj) {\n`,
      '  obj || (obj = {});\n',
      '  let __t;\n',
      '  let __p = "";\n',
      `  const __escapes = ${JSON.stringify(kHtmlEscapes)};\n`,
      `  const __escapesre = new RegExp('${reHtmlEscape}', 'g');\n`,
      `\n`,
      `  const __e = function(s) { `,
      `    return s ? s.replace(__escapesre, function(key) { return __escapes[key]; }) : '';`,
      `  };\n`,
      `  with (obj) {\n`,
    ]),
  );

  const end = ast.children.at(-1)?.end ?? { line: 0, column: 0 };
  const nodes = ast.children
    .reduce((chunk, node) => {
      let code: string | SourceNode | (SourceNode | string)[] = '';
      switch (node.kind) {
        case 'content':
          code = [
            new SourceNode(node.start.line, node.start.column, sourceUrl, '__p = __p'),
            ...node.content.split('\n').map((line, i, arr) => {
              return new SourceNode(
                node.start.line + i,
                i == 0 ? node.start.column : 0,
                sourceUrl,
                '\n    + ' + JSON.stringify(line + (i == arr.length - 1 ? '' : '\n')),
              );
            }),
            new SourceNode(node.end.line, node.end.column, sourceUrl, ';\n'),
          ];
          break;
        case 'interpolate':
          code = [
            new SourceNode(node.start.line, node.start.column, sourceUrl, '__p += ((__t = '),
            ...node.expression.split('\n').map((line, i, arr) => {
              return new SourceNode(
                node.start.line + i,
                i == 0 ? node.start.column : 0,
                sourceUrl,
                line + (i == arr.length - 1 ? '' : '\n'),
              );
            }),
            new SourceNode(node.end.line, node.end.column, sourceUrl, ') == null ? "" : __t);\n'),
          ];
          break;
        case 'escape':
          code = [
            new SourceNode(node.start.line, node.start.column, sourceUrl, '__p += __e('),
            ...node.expression.split('\n').map((line, i, arr) => {
              return new SourceNode(
                node.start.line + i,
                i == 0 ? node.start.column : 0,
                sourceUrl,
                line + (i == arr.length - 1 ? '' : '\n'),
              );
            }),
            new SourceNode(node.end.line, node.end.column, sourceUrl, ');\n'),
          ];
          break;
        case 'evaluate':
          code = [
            ...node.expression.split('\n').map((line, i, arr) => {
              return new SourceNode(
                node.start.line + i,
                i == 0 ? node.start.column : 0,
                sourceUrl,
                line + (i == arr.length - 1 ? '' : '\n'),
              );
            }),
            new SourceNode(node.end.line, node.end.column, sourceUrl, '\n'),
          ];
          break;
      }

      return chunk.add(new SourceNode(node.start.line, node.start.column, sourceUrl, code));
    }, preamble)
    .add(
      new SourceNode(end.line, end.column, sourceUrl, ['  };\n', '\n', '  return __p;\n', '}\n']),
    );

  const code = nodes.toStringWithSourceMap({
    file: sourceUrl,
    sourceRoot: (options && options.sourceRoot) || '.',
  });

  // Set the source content in the source map, otherwise the sourceUrl is not enough
  // to find the content.
  code.map.setSourceContent(sourceUrl, ast.content);

  return (
    code.code +
    '\n//# sourceMappingURL=data:application/json;base64,' +
    Buffer.from(code.map.toString()).toString('base64')
  );
}

/**
 * An equivalent of EJS templates, which is based on John Resig's `tmpl` implementation
 * (http://ejohn.org/blog/javascript-micro-templating/) and Laura Doktorova's doT.js
 * (https://github.com/olado/doT).
 *
 * This version differs from lodash by removing support from ES6 quasi-literals, and making the
 * code slightly simpler to follow. It also does not depend on any third party, which is nice.
 *
 * Finally, it supports SourceMap, if you ever need to debug, which is super nice.
 *
 * @param content The template content.
 * @param options Optional Options. See TemplateOptions for more description.
 * @return {(input: T) => string} A function that accept an input object and returns the content
 *         of the template with the input applied.
 */
export function template<T>(content: string, options?: TemplateOptions): (input: T) => string {
  const sourceUrl = (options && options.sourceURL) || 'ejs';
  const ast = templateParser(content, sourceUrl);

  let source: string;
  // If there's no need for source map support, we revert back to the fast implementation.
  if (options && options.sourceMap) {
    source = templateWithSourceMap(ast, options);
  } else {
    source = templateFast(ast, options);
  }

  // We pass a dummy module in case the module option is passed. If `module: true` is passed, we
  // need to only use the source, not the function itself. Otherwise expect a module object to be
  // passed, and we use that one.
  const fn = Function('module', source);
  const module =
    options && options.module ? (options.module === true ? { exports: {} } : options.module) : null;
  const result = fn(module);

  // Provide the compiled function's source by its `toString` method or
  // the `source` property as a convenience for inlining compiled templates.
  result.source = source;

  return result;
}
